weakself

Using Swift for Your CLI Tools | Background Jobs

A Brief Introduction to the Run Loop

Typically, we are used to work on iPhone or macOS applications, where we often face asynchronous tasks such as backend calls or managing timers etc.

When dealing with this kind of tasks in our scripts however, we need to take some key differences into account. Specifically we are going to focus on the differences on how the lifecycle of an iOS or macOS app behaves compared to that of a script.

Runloop on an iOS app

In an iOS app, the execution cycle is repeated thanks to the creation of a main run loop that keeps the app running and responds to user events. The run loop allows the user interface to remain responsive while handling other tasks in the background.

Runloop on a Script

If we look closely, the script executes linearly, meaning it runs from start to finish without an inherent event cycle. Scripts in Swift do not have a default run loop. If a script requires asynchronous processing or event handling similar to an iOS application, it would need to implement its own event cycle.

swift_cli_script_cycle

Handling Asynchronous Tasks | Semaphores

One way to handle these asynchronous tasks is by using Semaphores. A semaphore has an internal counter that you can adjust. When a thread wants to access the shared resource, it decrements the semaphore's counter. If the counter is greater than zero, the thread can proceed, and the semaphore automatically increments its counter when the thread releases the resource.

In this small example, we will pass the URL of a csv file as an argument to the script and attempt to print its lines.

To do this, we will write a class that takes care of downloading the csv using URLSession.



class CSVDownloader {
    init() {}
    
    func downloadCSV(with url: URL) {
        let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
            if let data = data,
               let csvString = String(data: data, encoding: .utf8) {
                print(csvString)   
            }
        }
        task.resume()
    }
}

Now, in our run() function of our script, we will create an instance of this downloader and pass the url to it.



@main
struct myCLITool: ParsableCommand {
    @Argument(help: "CSV URL")
    var input: String
    
    mutating func run() throws {
        guard let url = URL(string: input) else { return }
        let downlaoder = CSVDownloader()
        downlaoder.downloadCSV(with: url)
    }
}

Executing this code, we can observe that the script finishes its execution before the URL call is completed. That's why we will implement the first way to handle these asynchronous tasks, using semaphores. We will modify our CSVDownloader class as follows:



class CSVDownloader {
    init() {}
    
    func downloadCSV(with url: URL) {
        // 1
        let semaphore = DispatchSemaphore(value: 0)
        
        let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
            defer {
                // 3
                semaphore.signal()
            }
            
            if let data = data,
               let csvString = String(data: data, encoding: .utf8) {
                print(csvString)       
            } 
        }
        task.resume()
        // 2
        semaphore.wait()
    }
}

  1. We will create a semaphore with a value of 0.
Note: Remember that as soon as the semaphore value is 1, the thread can proceed.
  1. We will make the semaphore wait until its signal is incremented, thus preventing the script from finishing before the call.
  2. We will increment the semaphore signal.

This way, we can work with asynchronous tasks in our script using semaphores.

Handling Asynchronous Tasks | Creating a Run Loop

As we mentioned earlier, a RunLoop is a loop that waits for and responds to events, such as user input, timers, and network operations, without blocking the main thread. What we'll try in this approach is to make the main thread of our script enter an infinite loop and not finish until we indicate it.

Just like in the semaphore approach, we will create our CSVDownloader class.



class CSVDownloader {
    init() {}

    func downloadCSV(with url: URL) {
        let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
            if let data = data,
               let csvString = String(data: data, encoding: .utf8) {
                print(csvString)
                //1
                exit(EXIT_SUCCESS)
            } else {
                //2
                exit(EXIT_FAILURE)
            }
        }
        task.resume()
    }
}

  1. We will signal the thread that we have finished execution successfully.
  2. We will signal the thread that we have finished execution with an error.

In the run() function of our script, we will create an instance of our CSVDownloader and pass it the URL of the CSV to download.



@main
struct myCLITool: ParsableCommand {
    @Argument(help: "CSV URL")
    var input: String

    mutating func run()  throws {
        guard let url = URL(string: input) else { return }
        let downlaoder = CSVDownloader()
        downlaoder.downloadCSV(with: url)
        
        // 1
        RunLoop.main.run()
    }

}

  1. We will start our run loop

Handling Asynchronous Tasks | async/await

To finish, let's talk about async/await. If you've worked with it before, you know that it's an elegant and straightforward way to deal with asynchronous tasks.

Note: In our script, if we want to work with async/await and the ArgumentParser library, we'll have to make our struct conform to AsyncParsableCommand and make our run()function asynchronous.


@main
struct myCLITool: AsyncParsableCommand {
    @Argument(help: "CSV URL")
    var input: String

    mutating func run() async throws {
        let url = URL(string: input)!
        // 1
        for try await line in url.lines {
            print(line)
        }
    }
}

  1. As you can see the await keyword is used in asynchronous contexts to suspend the execution of a function until the asynchronous operation being awaited is completed. It allows the execution thread to be freed up for other tasks while waiting for the asynchronous operation to finish.

In our next installment, we will show how a script can also utilize an architecture, and we will also delve into how to conduct unit tests on our code.

Tagged with: